iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Software Development

異步程式設計修煉:Kotlin Coroutines 完全指南系列 第 7

Day 06:Coroutine Scope,協程住在哪裡、活多久

  • 分享至 

  • xImage
  •  

Day 05:把暫停機制與啟動方式放在一起跑一次》結尾留下一個問題沒有回答:協程數量一多,誰該負責統一管理它們,確保這些協程不會各自失控地一直跑下去。今天要正式回答這個問題。

一個協程需要一個家

三個協程還能靠人腦逐一推演,幾十個協程就不行了。靠肉眼一個個檢查誰活著、誰已經結束,這種做法從一開始就撐不久,勢必需要某種機制,把彼此相關的協程收在同一個地方管理。

答案是 Coroutine Scope。在 Kotlin 協程的設計裡,每一個協程都必須在某個 Coroutine Scope 裡啟動,不存在憑空遊蕩、沒有歸屬的協程。這個詞從今天起正式定案,全系列後續一律保留英文原文,不另外翻譯、不創中文譯名。

其實 Day 4 到 Day 5 一直都在用 Scope,只是沒點名

回頭看看前幾天寫過的程式碼,會發現一件有趣的事,Coroutine Scope 其實早就在場,只是一直沒被正式點名過。

Day 05:把暫停機制與啟動方式放在一起跑一次 那段整合範例裡,launchasync 都不是憑空被呼叫的,它們是在 runBlocking 大括號裡面被呼叫的。這個大括號圈起來的範圍,正是一個 Coroutine Scope。runBlocking 除了負責把執行緒帶進協程世界,同時也順手建立並提供了這樣一個環境,讓裡面的 launchasync 有地方可以掛。先前只講了 runBlocking 會阻塞執行緒這件事,卻沒提到它同時也扮演了 Scope 的角色,今天正式把這塊補上。

這不只是一個順便附帶的細節,而是 Kotlin 協程刻意做出的硬性限制。呼叫 launchasync 這類 Coroutine Builder 時,如果找不到一個 Scope 可以歸屬,程式碼根本無法通過編譯。試著在一個普通函式裡,沒有經過 runBlocking 或任何 Scope 就直接寫下 launch { ... },編譯器會直接擋下來,因為協程沒有一個天然可以存在的地方。這是設計上的刻意約束,不是語言團隊的疏忽或遺漏。

Scope 決定了協程活多久

Coroutine Scope 本身也有自己的生命週期,這件事帶出協程和傳統 Thread 一個很根本的行為差異。

傳統 Thread 一旦啟動,通常沒有一個天然機制會在某個父層結束時,自動要求它跟著停下來。你得自己想辦法追蹤這條 Thread,自己寫程式碼去中斷它,一旦漏了這道手續,它就會在背景默默跑下去,直到自己執行完畢為止。協程不是這樣運作的。當一個 Coroutine Scope 被取消或結束時,這個 Scope 裡啟動的所有協程,理論上也會被要求跟著結束,這種「跟著父層一起收尾」的行為,是內建在協程設計裡的。

拿多來源圖片下載與聚合工具具體想像一次。假設一次聚合下載任務對應一個 Coroutine Scope,這個 Scope 底下可能同時掛著十幾個下載協程,各自負責一個網址。如果使用者提早按下取消,或整個工具需要提前結束,這個 Scope 底下所有還在進行中的下載,理論上都該跟著被收拾掉,而不是繼續在背景默默跑完,白白花時間去下載一張早就沒人需要的圖片。

這裡先只停在「Scope 結束、協程跟著結束」這個方向性的認識就好。取消訊號實際上怎麼傳到協程內部,協程又是怎麼回應這個訊號,這些細節留到後面談協程取消機制的時候再展開,今天不碰。

自己建立一個 Coroutine Scope

前面一直在講 Scope,但還沒看過它長什麼樣子。建立一個最小的 Coroutine Scope,語法其實相當輕量。

val downloadScope = CoroutineScope(Job())

或者更常見的,直接在需要的地方建立並使用:

fun startAggregateDownload(urls: List<String>) {
    val downloadScope = CoroutineScope(Job())

    urls.forEach { url ->
        downloadScope.launch {
            val bytes = downloadImage(url)
            println("$url 下載完成,大小為 ${bytes.size} bytes")
        }
    }
}

Job() 這裡先不深入解釋它的完整定義,今天只需要知道,建立一個 Coroutine Scope 需要搭配這樣一個目前先不解釋的必要參數,之後系列會回頭正式說明它扮演的角色。延續前面的下載任務場景,urls.forEach 裡的每一次 downloadScope.launch,都是在同一個 Scope 裡啟動一個下載協程,它們共用同一個生命週期邊界。範例只示範到建立 Scope 與在其中啟動協程這個層級,至於這個 Scope 該在什麼時機被正式結束或取消,完整規則留到後面章節。

建立 Scope 這件事,語法本身談不上複雜,真正需要小心的地方在別處。不同的建立方式,會帶來完全不同的生命週期行為,例如一個從程式啟動就存在、幾乎不會結束的全域 Scope,跟一個綁定某個任務範圍、任務結束就該跟著結束的 Scope,兩者的責任完全不同。這個選擇本身就是一個重要的設計決策,會直接影響到協程管理起來是輕鬆還是麻煩。今天先建立這個概念,具體該怎麼選,留給後續章節依情境逐步展開。

同一個家裡的協程們,彼此是什麼關係

今天走到這裡,Scope 本身是什麼、它管的是誰,已經有了清楚的畫面。但還有一個問題被刻意留白。

同一個 Scope 底下,往往不會只養一個協程。如果其中一個先執行完了,或者其中一個中途發生了錯誤,這件事會不會影響到同一個 Scope 裡的其他協程?它們之間彼此到底是什麼關係,各自獨立互不相關,還是存在某種說不清楚的牽連?

這個問題今天先不回答。答案會在 《Day 07:Structured Concurrency,為什麼協程不能亂長亂放》 正式定案,那篇會揭開 Structured Concurrency,結構化並發,這個系列最重要的核心觀念之一。


上一篇
Day 05:把暫停機制與啟動方式放在一起跑一次
下一篇
Day 07:Structured Concurrency,為什麼協程不能亂長亂放
系列文
異步程式設計修煉:Kotlin Coroutines 完全指南8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言